Skip to content

feat(orchestration): let a service claim its own nodes - #3654

Merged
wprazuch merged 8 commits into
mainfrom
wprazuch/service-node-placement
Sep 28, 2026
Merged

wprazuch merged 8 commits into
mainfrom
wprazuch/service-node-placement

Conversation

@wprazuch

@wprazuch wprazuch commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

In one sentence

A service can now be given nodes of its own, instead of every service piling onto the same one.

The problem we hit

We moved a set of benchmarks from another launcher onto gym eval submit, and two of them could not be expressed at all.

  • WMT24++ with COMET. COMET scoring runs XCOMET-XXL, a second model that needs its own GPUs. The policy already fills all four GPUs of a node, and the scorer does not fit beside it. The previous launcher gave the scorer a second node. Here there was no way to ask for one, so the benchmark could only report spBLEU, with COMET turned off.
  • Prefill/decode serving. A large policy served for an agentic benchmark (SWE-bench Pro) splits into a prefill tier and a decode tier on separate nodes. That needs each tier placed on its own nodes. See feat(orchestration): express prefill/decode disaggregation #3657, which builds on this.

The cause is the same in both: SubmitConfig accepts several services, but each one renders as a bare srun --overlap with no node targeting. Every service starts on the same node and competes for its GPUs.

What changed

A service may name a node_pool. Pools take contiguous slices of the allocation in declaration order. The job script exports each pool's hosts, and a pinned service names them with srun --nodelist.

compute:
  my-cluster:
    node_pools:
      gpu: {partition: batch, nodes: 1, gpus_per_node: 4}
      aux: {partition: batch, nodes: 1, gpus_per_node: 4}
services:
  policy: {type: vllm, node_pool: gpu, ...}
  scorer: {type: vllm, node_pool: aux, ...}

Sizing follows placement. A service pinned to a one-node pool inside a ten-node job is a single-node deployment, so it is no longer built as a multi-node Ray one, and its GPU footprint is checked against its own pool rather than the allocation total.

The ray service does something now. _build_ray_command returned the fixed string ray start --head. A ray service now describes the cluster's shape: the node pools it spans, and custom resources per pool, such as {"extra_gpu": 4} — which is what a benchmark asks for when it places actors on a node's GPUs itself (see resources_servers/wmt_translation/app.py). The head starts beside the driver. Every other node of the spanned pools joins it as a worker, in the same container. Workers find the head through an address the job script exports once Slurm has placed the job.

services:
  policy: {type: vllm, node_pool: gpu, ...}
  comet:
    type: ray
    node_pools: [gpu, aux]
    resources:
      aux: {extra_gpu: 4}

Placement follows the policy. The driver reaches the policy on localhost, so in any multi-node job it runs on the policy's first node: node 0, or the first node of the policy's pool. An unpinned service that fits on one node goes there too.

Found on a real cluster

The first version pinned services with srun --relative. On oci-hsg that did not hold: --relative is only where Slurm starts looking, and once a node runs a step, a later step moves elsewhere. A ray head asked for node 0 and landed on node 1; the driver landed away from the policy and could not reach it. Both are fixed by naming hosts with --nodelist. Verified with a two-node job where a COMET scorer ran on its own node and produced scores, and again after the ray service became one cluster over both pools.

Two bugs this uncovered

  • Multiple node pools never worked. The #SBATCH directives were emitted once per pool, so each --nodes/--partition overwrote the last and only the final pool survived — while _node_totals and everything downstream sized itself on the sum. A two-pool job asked Slurm for one pool's nodes. There is now one directive block for the allocation, and pools that disagree on a whole-allocation directive are refused rather than resolved by declaration order.
  • ray start needs --block. Ray daemonises and returns, so the srun step exited as soon as the node came up and Slurm tore the service down again.

Testing

tests/unit_tests/test_slurm_script.py gains contiguous-range placement, the single-node-pinned-inside-a-multi-node-job case, unknown pool names, the pool-disagreement refusals, the ray service shape (head beside the driver, one worker step per spanned pool, per-pool resources), and health probes aimed at the node each service runs on. 355 orchestration tests pass; the failures in a full tests/unit_tests run are test_super_vllm_launcher.py, which fails identically on main.

@nemo-automation-bot
nemo-automation-bot Bot requested a review from a team September 23, 2026 08:23
@yaoyu-33 yaoyu-33 added area:orchestration Job submission, deployment, services, and lifecycle management complexity:high Cross-component or shared-contract change with a large review surface feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer labels Sep 23, 2026
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 24, 2026
A submit config could already declare several services, but every one of them
rendered as a bare `srun --overlap`, so they all landed on the same node and
competed for its GPUs. A scorer or judge that cannot share a GPU with the
policy had nowhere to go.

A service may now name a `node_pool`. Pools take contiguous slices of the
allocation in declaration order, and a pinned service is launched with
`srun --relative=<offset>`, which starts the step at that slice.

Sizing follows placement: a service pinned to a one-node pool inside a
ten-node job is a single-node deployment, so it is no longer built as a
multi-node Ray one, and its GPU footprint is checked against its own pool
instead of the allocation total.

This also fixes multiple pools being silently useless. The directives were
emitted per pool, so each --nodes/--partition overwrote the last and only the
final pool survived, while the executor sized itself on the sum. There is now
one directive block for the allocation, and pools that disagree on a
whole-allocation directive are refused instead of resolved by ordering.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
`_build_ray_command` returned the fixed string "ray start --head". That starts
a cluster nobody can join, on whichever node srun picked, and returns
immediately -- ray daemonises, so the srun step exited and Slurm tore the
service down again.

A ray service now takes a mode. A head starts a cluster on a chosen port; a
worker joins one at `address`. Either may advertise custom Ray resources
(`{"extra_gpu": 4}`), which is how a benchmark asks for a node whose GPUs it
places actors on itself rather than letting Ray count them. --block keeps the
step alive for as long as the node should be up.

With node_pool from the previous commit, this is enough to put such a worker
on a node of its own.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
A worker could only join a fixed host:port, but the head's host is not known
until Slurm places the job. A worker may now name a ray head service in the
same config (`head: ray_head`); the job script exports that head's address as
GYM_RAY_ADDRESS_<SERVICE>, resolved from its pool's first node, and the worker
joins through it. The driver can read the same variable to connect.

Also adds `extra_args` to ray services for flags the schema does not model,
such as fixed ports that avoid another Ray instance on the same node.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
--relative is only where Slurm starts looking. On a real two-node job a ray
head with --relative=0 landed on node 1, next to the worker, because node 0's
resources were held by the policy step; it then tried to reach node 0's IP and
never came up.

The job script now exports each pool's hosts (GYM_POOL_<NAME>_NODES) from the
allocation's node list, and a pinned service passes them to --nodelist. The
ray head address reuses the same host list.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
With services pinned to pools, the driver's srun had no node constraint, and on
a real two-node job Slurm ran it on the aux node. It reaches the policy on
localhost and joins a Ray head through that node's raylet, so both failed. The
driver now goes to the first node of the policy's pool.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The driver reaches the policy on localhost, but its srun only asked for one
node, so in a multi-node job Slurm could start it anywhere. A multi-node
policy serves its API from node 0, a pinned one from its pool's first node;
the driver now goes there in any multi-node job, not only when services are
pinned. An unpinned service that fits on one node follows it, for the same
reason.

Also brace the --nodelist expansion. Unbraced, $gym_nodes[0] expands to the
first host followed by a literal [0].

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
Comment thread nemo_gym/orchestration/api.py Outdated
# "head" starts a cluster; "worker" joins the one at `address`. A worker is how a
# second node joins the driver's Ray cluster and offers its GPUs to actors the
# benchmark schedules (e.g. a scorer that runs off the policy's node).
mode: Literal["head", "worker"] = "head"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an implementation detail that should be hidden away, right? The user should rather specify how many nodes should cluster spanb (or in general a shape of ray cluster) but not start individual workers / head.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might have missed something. If you think that it's important, please provide an example of config where user would do separate head/worker definitions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is too low-level, at least for now, since WMT is the only case I know and does not need it. I'll rework it into a single ray service that names the node pools it spans

@github-actions github-actions Bot removed the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 28, 2026
Comment on lines +362 to +368
if service.mode == "head":
cmd += f" --head --port {service.port}"
elif service.head is not None:
# Double-quoted, not shlex-quoted: the head's address is only known once
# Slurm places the job, and build_sbatch_script exports it under this name.
cmd += f' --address "${ray_head_address_var(service.head)}"'
else:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be consolidated into a single ray service. Conceptually, worker belongs to head and I don't think it's going to be switched to another cluster

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the same container

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, will be covered like that

Comment thread nemo_gym/orchestration/api.py Resolved
A ray service now names the node pools it spans instead of starting a head
and workers separately. The head starts beside the driver, every other node
of those pools joins it as a worker, and all of them use the service's one
container. `resources` is keyed by pool, so only the pools that need a custom
resource advertise it.

Removes `mode`, `address` and `head`. Workers wait for the head, and flags
Ray accepts only on a head are dropped from their command.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
The health probe runs from the batch script on node 0, but a pinned service
answers on its pool's first node, and a ray head and the collector follow the
driver's node. Probed on localhost, they waited out their timeout on a later
node. Probe them where _service_nodelist places them.

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
wprazuch added a commit that referenced this pull request Sep 28, 2026
Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>

@prokotg prokotg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved but agreed to carry automatic gpu placement in a follow-up PR

@wprazuch
wprazuch added this pull request to the merge queue Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/36421636406

Merged via the queue into main with commit b1d0dff Sep 28, 2026
41 checks passed
@wprazuch
wprazuch deleted the wprazuch/service-node-placement branch September 28, 2026 12:38
pull Bot pushed a commit to Stars1233/Gym that referenced this pull request Sep 30, 2026
…Mo#3763)

Same change as NVIDIA-NeMo#3657, rebased onto main on a new branch; NVIDIA-NeMo#3657's branch
stays untouched for now.

## In one sentence

Express prefill/decode disaggregated serving, so a policy can be split
across two tiers behind a router.

> Builds on NVIDIA-NeMo#3654 (per-service node placement, now on main): prefill and
decode need node pools of their own.

## Why

A large policy served for an agentic benchmark spends most of its time
in prefill. Splitting prefill and decode onto separate node tiers, with
KV cache transferred between them and a router in front, is how that
workload is served in practice today. `SubmitConfig` had no way to say
it.

## The change

One new service type, `vllm_pd`. It nests its two tiers and owns the
router and KV settings:

```yaml
services:
  policy:
    type: vllm_pd
    container: vllm/vllm-openai:latest
    model: /models/checkpoint
    served_model_name: my-model
    port: 8000                     # the router; driver.policy_model names this service
    prefill:
      node_pool: prefill
      tensor_parallel_size: 4
      number_of_instances: 4
    decode:
      node_pool: decode
      tensor_parallel_size: 4
      number_of_instances: 6
    # optional: server_per_node (false), nixl_side_channel_port (5600; decode +1),
    # prefill_policy, decode_policy, kv_connector (NixlConnector),
    # kv_load_failure_policy (fail), request_timeout_secs, log_level
driver:
  policy_model: policy
```

- `prefill:` and `decode:` are plain vLLM service settings. Each
inherits `container`, `model` and `served_model_name`.
- Every P/D setting lives on the `vllm_pd` service. `server_per_node`
applies to both tiers. `nixl_side_channel_port` is prefill's; decode
gets it + 1. Setting either on a tier is refused.
- Each tier gets its KV role automatically.
- Tier ports default to router port + 1 and + 2. Decode's data-parallel
port defaults to prefill's + 1. All can be set.

Why `vllm_pd`: the service is a vLLM prefill/decode deployment, not a
general router. A different kind of router can have its own type later.

No logic is duplicated. At validation, the service expands into three
internal services: `policy-prefill`, `policy-decode` and `policy` (the
router). The existing multi-node rank builder, node placement and health
checks run on those unchanged. The resolved config written to the job
directory keeps the nested form, so it loads back.

## Bugs found by generating a real job script

Before running anything, we generated the job script for SWE-bench Pro's
real layout (4 prefill nodes, 6 decode nodes) and read it. That found
three bugs, all still guarded:

- **Both tiers used the same meeting point.** Each tier's vLLM servers
now coordinate on their own first node and their own port.
- **Decode's health check looked in the wrong place.** Each tier and the
router are now probed on the node they run on.
- **Nothing stopped the two tiers from picking the same ports.**
Validation refuses a data-parallel port clash and names both tiers. The
NIXL ports are derived, so they cannot clash.

## Found on a real cluster

A SWE-bench Pro prefill/decode run on a real cluster exposed four more
problems. All are fixed:

- **One server per node.** A tier with `server_per_node: true` runs an
independent `vllm serve` on every node, and the router lists each one.
- **The router listens on `0.0.0.0`.** The driver and the health probe
call it on `localhost`.
- **The router and driver share node 0.** This comes from NVIDIA-NeMo#3654's
placement rule.
- **`driver.policy_host`.** An agent in a remote sandbox cannot reach
the driver's `localhost`, so the injected policy URL can name a routable
host.

## Testing

- **Same job script as before.** One commit renders SWE-bench Pro's
layout with the earlier three-service form and pins the output as
fixtures. There are two variants: data-parallel tiers and one server per
node. The next commit switches to `vllm_pd`, and the new form renders
both fixtures byte for byte. The collector's scrape config is also
unchanged.
- **Unit tests.** Tests cover the expansion, inheritance and port
defaults. They also check that the resolved config loads back. Refusal
tests cover: a port clash, a tier without a pool or with an unknown
pool, a missing tier, a name that clashes with a tier's name, KV fields
or `server_per_node` / `nixl_side_channel_port` set on a tier, and those
fields set on a plain vLLM service.
- **Each new test was revert-checked.** It fails when the piece it
guards is removed.
- Non-P/D rendering is unchanged.

## Verified end to end

SWE-bench Pro ran the full 4 prefill + 6 decode shape on a real cluster
with this change. That was before the config was reshaped, and the job
script is byte-identical. All 731 tasks x 3 = 2193 rollouts finished.
Mean reward was 0.533, against a reference of 0.526 from the same
checkpoint served another way.

A deployment still needs its fabric environment (`UCX_*`, `NCCL_*`)
supplied through each tier's `env` or `pre_command`. This PR does not
presume a set.

---------

Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>

This branch was previously deployed

1 inactive deployment
public — 4808c748 Deployed Sep 28, 2026 by copy-pr-bot[bot] via release / finalize / notify #3673
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:orchestration Job submission, deployment, services, and lifecycle management complexity:high Cross-component or shared-contract change with a large review surface feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants